ποΈGitΠ―ΡΠ°ποΈ
Commit 91629c3e5b8d942b6dbc8aa20665e962a08bce92
Parents : bf2338c
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-21T17:41:55-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-21T22:41:55Z
fix(connections): coordinate BLE and TCP scan lifecycle (#5887)
Changes
4 files changed, 303 insertions(+), 23 deletions(-)
Diff
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
index 21825126e8..792a306f50 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
@@ -75,6 +75,8 @@ open class ScannerViewModel(
// ββ Mock / demo transport βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private val _showMockTransport = MutableStateFlow(false)
+
+ /** Whether the mock/demo transport is currently selected. */
val showMockTransport: StateFlow<Boolean> = _showMockTransport.asStateFlow()
// ββ Connection-progress chatter (surfaced as the bottom status pill) ββββββββββββββββββββββ
@@ -91,6 +93,8 @@ open class ScannerViewModel(
// ββ BLE scanning ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private val _isBleScanning = MutableStateFlow(false)
+
+ /** Whether a BLE scan is currently active. */
val isBleScanning: StateFlow<Boolean> = _isBleScanning.asStateFlow()
/** User preference that controls whether BLE scanning auto-starts when the Connections screen opens. */
@@ -100,8 +104,16 @@ open class ScannerViewModel(
private val discoveryOrder = MutableStateFlow<List<String>>(emptyList())
private var scanJob: Job? = null
+ // Generation counter that owns the `_isBleScanning` flag's cleanup. The scan coroutine's `finally` block may run
+ // asynchronously on the IO dispatcher after a stop+restart has already kicked off a new scan; without this guard
+ // the old job's finally would reset the flag on the new scan's state. Bumped on each start and each stop so that
+ // only the current generation's finally may clear the flag.
+ private val scanGeneration = MutableStateFlow(0)
+
// ββ Network scanning (NSD gating) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private val _isNetworkScanning = MutableStateFlow(false)
+
+ /** Whether an NSD network scan is currently active. */
val isNetworkScanning: StateFlow<Boolean> = _isNetworkScanning.asStateFlow()
/** User preference that controls whether NSD network scanning auto-starts when the Connections screen opens. */
@@ -118,10 +130,13 @@ open class ScannerViewModel(
/** Whether the USB section is visible in the Connections device list. Defaults to `true`. */
val showUsbTransport: StateFlow<Boolean> = uiPrefs.showUsbTransport
+ /** Toggles whether the BLE section is visible in the Connections device list. */
fun setShowBleTransport(enabled: Boolean) = uiPrefs.setShowBleTransport(enabled)
+ /** Toggles whether the Network (TCP/NSD) section is visible in the Connections device list. */
fun setShowNetworkTransport(enabled: Boolean) = uiPrefs.setShowNetworkTransport(enabled)
+ /** Toggles whether the USB section is visible in the Connections device list. */
fun setShowUsbTransport(enabled: Boolean) = uiPrefs.setShowUsbTransport(enabled)
/**
@@ -209,14 +224,17 @@ open class ScannerViewModel(
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
discoveredDevicesFlow.map { it.usbDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList())
+ /** Discovered (NSD) TCP devices for the Connections device list, gated by the network-scan flag. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
discoveredDevicesFlow.map { it.discoveredTcpDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList())
+ /** Recently-used TCP addresses for the Connections device list. */
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
discoveredDevicesFlow.map { it.recentTcpDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList())
// ββ Current selection ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** The currently-selected device address, or `null` when nothing is selected. */
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
/** The persisted device name from the last selection, for use as a UI fallback. */
@@ -230,10 +248,21 @@ open class ScannerViewModel(
// ββ Scan commands ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /**
+ * Starts BLE scanning. Enforces mutual exclusion (cancels any active network scan first). No-op if already scanning
+ * or if [bleScanner] is null.
+ *
+ * The `finally` that clears [_isBleScanning] is guarded by a generation counter so a stale cancellation from a
+ * prior scan cannot reset the flag on this new scan's state.
+ */
fun startBleScan() {
if (_isBleScanning.value || bleScanner == null) return
+ // Cancel the other scan first so only one flag is ever true. Both stop methods are idempotent.
+ stopNetworkScan()
_isBleScanning.value = true
+ val generation = scanGeneration.value + 1
+ scanGeneration.value = generation
scanJob =
safeLaunch(tag = "startBleScan") {
@@ -257,62 +286,129 @@ open class ScannerViewModel(
}
}
} finally {
- _isBleScanning.value = false
+ if (scanGeneration.value == generation) _isBleScanning.value = false
}
}
}
+ /**
+ * Cancels the active BLE scan and resets the scanning flag. Idempotent.
+ *
+ * Bumps [scanGeneration] so any in-flight `finally` from the cancelled job cannot reset `_isBleScanning` after a
+ * subsequent [startBleScan] has flipped it back to `true`.
+ */
fun stopBleScan() {
scanJob?.cancel()
scanJob = null
+ scanGeneration.value = scanGeneration.value + 1
_isBleScanning.value = false
}
- /** Convenience command: start scanning if idle, stop otherwise. Persists the resulting state to prefs. */
+ /**
+ * Toggles BLE scanning. Persists the auto-scan preference only when the scan actually activates, and clears the
+ * opposite [networkAutoScan] preference to keep persisted state consistent with the runtime mutual-exclusion
+ * invariant.
+ */
fun toggleBleScan() {
- if (_isBleScanning.value) stopBleScan() else startBleScan()
- uiPrefs.setBleAutoScan(_isBleScanning.value)
+ if (_isBleScanning.value) {
+ stopBleScan()
+ uiPrefs.setBleAutoScan(false)
+ } else {
+ startBleScan()
+ // Only persist enable-intent (and clear the opposite pref) if start actually worked β e.g. not
+ // blocked by a null bleScanner.
+ if (_isBleScanning.value) {
+ uiPrefs.setBleAutoScan(true)
+ uiPrefs.setNetworkAutoScan(false)
+ }
+ }
}
+ /**
+ * Starts NSD network scanning. Enforces the same mutual-exclusion invariant as [startBleScan]; starting Network
+ * cancels any active BLE scan first.
+ */
fun startNetworkScan() {
+ if (_isNetworkScanning.value) return
+ // Cancel the other scan first so only one flag is ever true. Both stop methods are idempotent.
+ stopBleScan()
_isNetworkScanning.value = true
}
+ /** Cancels the active network scan and resets the scanning flag. Idempotent. */
fun stopNetworkScan() {
_isNetworkScanning.value = false
}
- /** Convenience command: start scanning if idle, stop otherwise. Persists the resulting state to prefs. */
+ /** Stops both BLE and network scans. Idempotent β safe to call when neither scan is active. */
+ private fun stopAllScans() {
+ stopBleScan()
+ stopNetworkScan()
+ }
+
+ /**
+ * Toggles network scanning. Persists the auto-scan preference only when the scan actually activates, and clears the
+ * opposite [bleAutoScan] preference to keep persisted state consistent with the runtime mutual-exclusion invariant.
+ */
fun toggleNetworkScan() {
- if (_isNetworkScanning.value) stopNetworkScan() else startNetworkScan()
- uiPrefs.setNetworkAutoScan(_isNetworkScanning.value)
+ if (_isNetworkScanning.value) {
+ stopNetworkScan()
+ uiPrefs.setNetworkAutoScan(false)
+ } else {
+ startNetworkScan()
+ // Only persist enable-intent (and clear the opposite pref) if start actually worked.
+ if (_isNetworkScanning.value) {
+ uiPrefs.setNetworkAutoScan(true)
+ uiPrefs.setBleAutoScan(false)
+ }
+ }
}
/**
- * Persist the user's intent to auto-scan the network on next screen entry without flipping the active scan flag.
+ * Persists the user's intent to auto-scan the network on next screen entry without flipping the active scan flag.
* Used by the Connections screen when it must defer the actual scan start until after the system permission grant
- * dialog resolves β the persisted intent ensures auto-start fires once permission is granted.
+ * dialog resolves. When [enabled] is `true`, also clears [bleAutoScan] so persisted state mirrors the runtime
+ * mutual-exclusion invariant (at most one of [bleAutoScan] / [networkAutoScan] may be true).
*/
fun persistNetworkAutoScanIntent(enabled: Boolean) {
uiPrefs.setNetworkAutoScan(enabled)
+ if (enabled) uiPrefs.setBleAutoScan(false)
}
// ββ Device selection / disconnect βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Asynchronously tells the radio controller to connect to [address]. */
fun changeDeviceAddress(address: String) {
Logger.i { "Attempting to change device address to ${address.anonymize()}" }
safeLaunch(tag = "changeDeviceAddress") { radioController.setDeviceAddress(address) }
}
+ /**
+ * Persists [address] in the recent-TCP list under [name]. No-op when [address] does not start with
+ * [TCP_DEVICE_PREFIX].
+ */
fun addRecentAddress(address: String, name: String) {
if (!address.startsWith(TCP_DEVICE_PREFIX)) return
safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) }
}
+ /** Removes [address] from the recent-TCP list. */
fun removeRecentAddress(address: String) {
safeLaunch(tag = "removeRecentAddress") { recentAddressesDataSource.remove(address) }
}
+ /**
+ * Connects to a manually-entered TCP address. Wraps the manual-entry flow with the same scan-cancel invariant as
+ * [onSelected]: stops discovery before connection setup so the manual connect does not race an in-progress
+ * BLE/network scan for radio resources.
+ */
+ fun connectToManualAddress(fullAddress: String) {
+ val displayAddress = fullAddress.removePrefix(TCP_DEVICE_PREFIX)
+ stopAllScans()
+ addRecentAddress(fullAddress, displayAddress)
+ changeDeviceAddress(fullAddress)
+ }
+
/**
* Called by the UI when a device has been tapped. BLE and USB entries may still need bonding/permission β the
* concrete return value tells the caller whether the connection was initiated immediately.
@@ -320,6 +416,10 @@ open class ScannerViewModel(
* @return `true` if the connection has been initiated; `false` if bonding/permission is pending.
*/
fun onSelected(entry: DeviceListEntry): Boolean {
+ // Stop discovery the moment the user picks a device, before any connection setup runs. The connect
+ // attempt (BLE GATT or TCP) contends with an active BLE scan for the same radio resources during the
+ // handshake; cancelling here keeps the lifecycle ordered: scan β stop β connect.
+ stopAllScans()
radioPrefs.setDevName(entry.name)
addRecentAddress(entry.fullAddress, entry.name)
return when (entry) {
@@ -371,8 +471,10 @@ open class ScannerViewModel(
changeDeviceAddress(entry.fullAddress)
}
+ /** Platform hook for requesting USB permission before connecting; default is a no-op. */
protected open fun requestPermission(entry: DeviceListEntry.Usb) = Unit
+ /** Clears the persisted device name and tells the radio controller to disconnect. */
fun disconnect() {
radioPrefs.setDevName(null)
changeDeviceAddress(NO_DEVICE_SELECTED)
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index e03aab1bfb..dac08e1c45 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -84,7 +84,6 @@ import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.feature.connections.REPLAY_DEVICE_PREFIX
import org.meshtastic.feature.connections.ScannerViewModel
-import org.meshtastic.feature.connections.TCP_DEVICE_PREFIX
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.ui.components.ConnectingDeviceInfo
import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo
@@ -131,8 +130,6 @@ fun ConnectionsScreen(
val isBleScanning by scanModel.isBleScanning.collectAsStateWithLifecycle()
val isNetworkScanning by scanModel.isNetworkScanning.collectAsStateWithLifecycle()
- val bleAutoScan by scanModel.bleAutoScan.collectAsStateWithLifecycle()
- val networkAutoScan by scanModel.networkAutoScan.collectAsStateWithLifecycle()
val showBleTransport by scanModel.showBleTransport.collectAsStateWithLifecycle()
val showNetworkTransport by scanModel.showNetworkTransport.collectAsStateWithLifecycle()
val showUsbTransport by scanModel.showUsbTransport.collectAsStateWithLifecycle()
@@ -152,13 +149,21 @@ fun ConnectionsScreen(
// Auto-start BLE scan when the screen is visible (lifecycle β₯ STARTED) and the user has previously opted in.
// LifecycleStartEffect stops scanning on ON_STOP (app backgrounded) and restarts on ON_START β preventing
// continuous background BLE radio usage that drains the battery.
- LifecycleStartEffect(bleAutoScan) {
- if (bleAutoScan && !scanModel.isBleScanning.value) scanModel.startBleScan()
+ // Keyed on Unit so the effect fires only on lifecycle events, not on preference writes. The toggle handler
+ // starts/stops scans directly; this effect handles screen-entry auto-start only. Keying on the pref caused
+ // Compose to dispose the running scan (calling stopBleScan) and immediately re-run (calling startBleScan)
+ // every time the pref was written, which cycled scans until Android's BluetoothLeScanner rate-limited.
+ LifecycleStartEffect(Unit) {
+ if (scanModel.bleAutoScan.value && !scanModel.isBleScanning.value) scanModel.startBleScan()
onStopOrDispose { scanModel.stopBleScan() }
}
- LifecycleStartEffect(networkAutoScan, localNetworkPermission.isGranted) {
- if (networkAutoScan && localNetworkPermission.isGranted) scanModel.startNetworkScan()
+ // Keyed on permission status (not on the pref) so the effect re-fires when the user grants local-network
+ // permission, but not when the pref is toggled. This prevents the dispose+restart cycle that caused
+ // Android's BluetoothLeScanner rate-limit rejection, while still supporting the request-permission β
+ // grant β auto-start flow. The body reads the pref directly via the StateFlow's current value.
+ LifecycleStartEffect(localNetworkPermission.isGranted) {
+ if (scanModel.networkAutoScan.value && localNetworkPermission.isGranted) scanModel.startNetworkScan()
onStopOrDispose { scanModel.stopNetworkScan() }
}
@@ -296,8 +301,8 @@ fun ConnectionsScreen(
// Adapter-off hints: shown only when the relevant permission is granted but the radio/network
// is unavailable, so they don't overlap the permission-recovery flow on the scan toggles.
// The Wi-Fi banner gate includes `isNetworkScanning` because `LifecycleStartEffect` keys the
- // auto-scan off `networkAutoScan + permission`, not the section-visibility chip β a user with
- // the Network filter off but auto-scan on still has a running scan that needs the hint.
+ // auto-scan off `permission status`, not the section-visibility chip β a user with the Network
+ // filter off but auto-scan on still has a running scan that needs the hint.
if (showBleTransport && bluetoothPermission.isGranted && bluetoothDisabled) {
RecoveryCard(
message = stringResource(Res.string.bluetooth_disabled),
@@ -374,9 +379,7 @@ fun ConnectionsScreen(
}
},
onAddManualAddress = { _, fullAddress ->
- val displayAddress = fullAddress.removePrefix(TCP_DEVICE_PREFIX)
- scanModel.addRecentAddress(fullAddress, displayAddress)
- scanModel.changeDeviceAddress(fullAddress)
+ scanModel.connectToManualAddress(fullAddress)
},
onRemoveRecentAddress = { scanModel.removeRecentAddress(it.fullAddress) },
)
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
index e9531f675b..0e28663313 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
@@ -19,14 +19,18 @@ package org.meshtastic.feature.connections
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
+import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.di.CoroutineDispatchers
@@ -104,6 +108,11 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
every { networkRepository.resolvedList } returns resolvedServicesFlow
every { networkRepository.networkAvailable } returns flowOf(true)
+ // Default: a non-completing scan flow so the BLE scan stays "active" until explicitly cancelled.
+ // Under UnconfinedTestDispatcher an emptyFlow().collect{} returns immediately and would flip
+ // _isBleScanning back to false before startBleScan() returns. Tests that need specific emissions
+ // (e.g. device-list updates) override this stub.
+ every { bleScanner.scan(any(), any()) } returns flow<BleDevice> { awaitCancellation() }
}
/**
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index 3cf94f0c9f..1bf711b1af 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.feature.connections.model.DeviceListEntry
@@ -91,8 +92,6 @@ class ScannerViewModelTest {
@Test
fun `startBleScan updates isBleScanning`() = runTest {
- every { bleScanner.scan(any(), any()) } returns kotlinx.coroutines.flow.emptyFlow()
-
viewModel.isBleScanning.test {
assertEquals(false, awaitItem())
viewModel.startBleScan()
@@ -270,4 +269,171 @@ class ScannerViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
+
+ // ββ Mutual exclusion: only one of BLE / Network scanning may be active at a time ββββββββββ
+
+ @Test
+ fun `startBleScan cancels active network scan`() = runTest {
+ viewModel.startNetworkScan()
+ assertEquals(true, viewModel.isNetworkScanning.value)
+
+ viewModel.startBleScan()
+
+ assertEquals(false, viewModel.isNetworkScanning.value)
+ assertEquals(true, viewModel.isBleScanning.value)
+ }
+
+ @Test
+ fun `startNetworkScan cancels active ble scan`() = runTest {
+ viewModel.startBleScan()
+ assertEquals(true, viewModel.isBleScanning.value)
+
+ viewModel.startNetworkScan()
+
+ assertEquals(false, viewModel.isBleScanning.value)
+ assertEquals(true, viewModel.isNetworkScanning.value)
+ }
+
+ // ββ Scanning allowed in any connection state ββββββββββββββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `startBleScan succeeds while Connected`() = runTest {
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ testScheduler.advanceUntilIdle()
+
+ viewModel.startBleScan()
+
+ assertEquals(true, viewModel.isBleScanning.value)
+ }
+
+ @Test
+ fun `startNetworkScan succeeds while Connected`() = runTest {
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ testScheduler.advanceUntilIdle()
+
+ viewModel.startNetworkScan()
+
+ assertEquals(true, viewModel.isNetworkScanning.value)
+ }
+
+ @Test
+ fun `startBleScan succeeds while Connecting`() = runTest {
+ serviceRepository.setConnectionState(ConnectionState.Connecting)
+ testScheduler.advanceUntilIdle()
+
+ viewModel.startBleScan()
+
+ assertEquals(true, viewModel.isBleScanning.value)
+ }
+
+ @Test
+ fun `startBleScan succeeds while DeviceSleep`() = runTest {
+ serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
+ testScheduler.advanceUntilIdle()
+
+ viewModel.startBleScan()
+
+ assertEquals(true, viewModel.isBleScanning.value)
+ }
+
+ @Test
+ fun `connectionState transition does not cancel active scan`() = runTest {
+ viewModel.startBleScan()
+ assertEquals(true, viewModel.isBleScanning.value)
+
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ testScheduler.advanceUntilIdle()
+
+ assertEquals(true, viewModel.isBleScanning.value)
+ }
+
+ // ββ Toggle persistence: enable clears the opposite persisted auto-scan pref ββββββββββββββ
+
+ @Test
+ fun `toggleBleScan enabling scan clears networkAutoScan`() = runTest {
+ harness.uiPrefs.setNetworkAutoScan(true)
+ assertEquals(true, viewModel.networkAutoScan.value)
+
+ viewModel.toggleBleScan()
+
+ // Successful enable persisted bleAutoScan=true AND cleared the opposite pref to mirror the runtime
+ // mutual-exclusion invariant in persisted state.
+ assertEquals(true, viewModel.isBleScanning.value)
+ assertEquals(true, viewModel.bleAutoScan.value)
+ assertEquals(false, viewModel.networkAutoScan.value)
+ }
+
+ @Test
+ fun `toggleNetworkScan enabling scan clears bleAutoScan`() = runTest {
+ harness.uiPrefs.setBleAutoScan(true)
+ assertEquals(true, viewModel.bleAutoScan.value)
+
+ viewModel.toggleNetworkScan()
+
+ assertEquals(true, viewModel.isNetworkScanning.value)
+ assertEquals(true, viewModel.networkAutoScan.value)
+ assertEquals(false, viewModel.bleAutoScan.value)
+ }
+
+ // ββ onSelected stops active scan before connection setup ββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `onSelected bonded BLE stops active scan before changing device`() = runTest {
+ val entry =
+ DeviceListEntry.Ble(device = FakeBleDevice(address = "01:02:03:04:05:06", name = "Node"), bonded = true)
+
+ viewModel.startBleScan()
+ assertEquals(true, viewModel.isBleScanning.value)
+
+ viewModel.onSelected(entry)
+ testScheduler.advanceUntilIdle()
+
+ // stopAllScans() runs before changeDeviceAddress() in onSelected β scan flag flips to false and
+ // the radio controller sees the new address.
+ assertEquals(false, viewModel.isBleScanning.value)
+ assertEquals(entry.fullAddress, radioController.lastSetDeviceAddress)
+ }
+
+ @Test
+ fun `onSelected TCP stops active network scan before changing device`() = runTest {
+ val entry = DeviceListEntry.Tcp(name = "TCP Node", fullAddress = "t192.168.1.50")
+
+ viewModel.startNetworkScan()
+ assertEquals(true, viewModel.isNetworkScanning.value)
+
+ viewModel.onSelected(entry)
+ testScheduler.advanceUntilIdle()
+
+ assertEquals(false, viewModel.isNetworkScanning.value)
+ assertEquals(entry.fullAddress, radioController.lastSetDeviceAddress)
+ }
+
+ // ββ persistNetworkAutoScanIntent invariant βββββββββββββββββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `persistNetworkAutoScanIntent true clears bleAutoScan`() = runTest {
+ harness.uiPrefs.setBleAutoScan(true)
+ assertEquals(true, viewModel.bleAutoScan.value)
+
+ viewModel.persistNetworkAutoScanIntent(true)
+
+ // Persisting a network-auto-scan intent must clear the opposite BLE pref so persisted state
+ // mirrors the runtime mutual-exclusion invariant β at most one of the two may be true.
+ assertEquals(false, viewModel.bleAutoScan.value)
+ assertEquals(true, viewModel.networkAutoScan.value)
+ }
+
+ // ββ connectToManualAddress stops scans before changing device ββββββββββββββββββββββββββββ
+
+ @Test
+ fun `connectToManualAddress stops active network scan and changes device address`() = runTest {
+ viewModel.startNetworkScan()
+ assertEquals(true, viewModel.isNetworkScanning.value)
+
+ viewModel.connectToManualAddress("t192.168.1.99")
+ testScheduler.advanceUntilIdle()
+
+ assertEquals(false, viewModel.isNetworkScanning.value)
+ assertEquals("t192.168.1.99", radioController.lastSetDeviceAddress)
+ }
}
Served by rngit 1.5.2 - Generated in 0.11s